Skip to content

test: lock client-router query-string preservation on every nav path - #926

Merged
vivek7405 merged 5 commits into
mainfrom
fix/client-router-query-params
Jul 12, 2026
Merged

test: lock client-router query-string preservation on every nav path#926
vivek7405 merged 5 commits into
mainfrom
fix/client-router-query-params

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #639

Query params sometimes appeared NOT to carry forward while dogfooding the blog. A source review of packages/core/src/router-client.js found the router mostly correct: finalUrl defaults to the full link href (so pushState keeps the query), the snapshot + prefetch cache key on pathname + search (so ?x=1 and ?x=2 are distinct), and the GET-form url.search = '' rebuild is the spec-compliant HTML5 behaviour. So this is primarily locked browser coverage that will ALSO surface any genuine drop.

What it adds

packages/core/test/routing/browser/query-params.test.js (real browser, all three wtr engines):

  • a link with a query preserves it in location.search AND in the URL the router fetches;
  • sequential navs keep DISTINCT params (/a?x=1 then /b?y=2 lands ?y=2, not merged, not dropped);
  • the prefetch cache is keyed by search (a different / absent search value is a cache MISS);
  • back/forward restores the prior entry's query string;
  • a GET-form submission REPLACES the action's query with the form fields (spec-correct, asserted so it is not later "fixed" into a regression);
  • a server redirect updates the URL to the redirect target's query;
  • (from a Next.js / Remix cross-check) repeated keys ?foo=bar&foo=baz stay a DISTINCT cache key from the single-value form (WebJs keys on the raw pathname+search, so it does not collapse them the way Next's #92787 bug did); a hash + a query survive a nav together and a hash change does not drop the query; an encoded / unicode query value round-trips.

Result: all nine cases pass on Chromium, Firefox, and WebKit, so no genuine query-string drop exists in the current router. No source change was needed.

Definition-of-done surfaces

  • Tests (browser): the new file (the headline layer for a client-router behaviour). Counterfactuals to follow in the PR.
  • Docs: N/A because no behaviour changed, and the one deliberate case (GET-form replaces the action query) is already documented accurately in agent-docs/advanced.md ("Form submissions", the GET bullet).
  • Source / scaffold / MCP / editors / bun-parity: N/A because this is a test-only change (no packages/*/src, no generated-app or editor surface).

Test plan

  • All 9 cases pass on all three browser engines
  • Counterfactuals: reverting finalUrl-keeps-search / the GET-form reset / the cacheKey search each reds the matching case (see the counterfactual comment)
  • Cross-checked against Next / Remix; added repeated-keys, hash+query, and encoded-value cases

…639)

Query params sometimes appeared not to carry forward while dogfooding the
blog. A source review found the router mostly correct (finalUrl defaults
to the full href, the snapshot/prefetch cache keys on pathname+search, the
GET-form reset is spec-compliant), so this is primarily locked browser
coverage that also surfaces any genuine drop.

Adds packages/core/test/routing/browser/query-params.test.js: a link with
a query preserves location.search + the fetched URL; sequential navs keep
distinct params (not merged, not dropped); the prefetch cache is keyed by
search; back/forward restores the prior entry query; a GET-form submission
replaces the action query with the form fields (spec-correct, asserted so
it is not mistaken for a bug); a redirect updates the URL to the target
query. Passes on Chromium, Firefox, and WebKit, so no genuine drop exists.

Closes #639
@vivek7405 vivek7405 self-assigned this Jul 12, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Counterfactual proof: the lock has teeth (all three router mechanisms)

Since this is a coverage-lock PR, I confirmed each assertion actually fails when the router line it guards is broken. Three source reverts cover all six cases (each run on Chromium + Firefox + WebKit):

  • Drop the search from cacheKey (u.pathname + u.search -> u.pathname): only the prefetch-keying case reds (the ?x=2 / query-less peek starts HITTING the ?x=1 entry). 5 pass, 1 fail.
  • Remove the GET-form url.search = '' reset: only the GET-form case reds (?old=1 is no longer replaced, it merges with the form fields). 5 pass, 1 fail.
  • Strip the search from finalUrl (the history URL): the nav-preserve, sequential, back/forward, and GET-form location.search cases all red (4 fail), while prefetch-keying and redirect stay green (redirect overwrites finalUrl with resp.url, prefetch does not use finalUrl).

So the six cases genuinely exercise the three distinct query paths (history URL, cache key, form-submission reset), not just a happy path. No source change was needed: the router already preserves query strings correctly on every path, which is what the review found and this pins.

Cross-referenced Next.js and Remix client-router query-string test suites
for edge cases worth locking:
- repeated keys (?foo=bar&foo=baz) are a DISTINCT cache key from the
  single-value form. Next had a real bug here (#92787): its key collapsed
  repeated keys via Object.fromEntries, keeping only the last value, so a
  multi-value to single-value nav was a false cache hit. WebJs keys on the
  raw pathname+search string, so it does not collapse; lock that.
- a hash and a query survive a nav together, and a hash change does not
  drop the query.
- an encoded / unicode query value round-trips through the nav.

All nine cases pass on Chromium, Firefox, and WebKit.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked each case against the router source to make sure none pass vacuously. The prefetch-keying case genuinely populates the cache (swapBody has a text/html 200 so it reaches prefetchStore) and the ?x=2 / query-less peeks miss only because cacheKey is pathname+search. The back/forward case asserts ?y=2 before history.back() so the ?x=1 restore is a real popstate observation, not a pre-set value. The redirect case's defineProperty shadows the Response getters and drives the finalUrl=resp.url branch. Isolation holds: teardown restores fetch + replaceState(before) + _resetPrefetch, and each test pushes its own entries before going back. The GET-form action really carries ?old=1 so the replace assertion has teeth. Nice and tight.

Replace the single fixed 25ms delay with a poll for the routed fetch, so
the GET-form case cannot flake on a slow CI runner. The other cases already
await navigate() to completion; this brings the one click-driven case in
line.
The prior commit polled for the routed fetch, which is recorded BEFORE the
pushState that sets location.search, so the location.search assertion ran
too early and failed. Poll location.search (which settles last) instead, so
the whole submission chain has run before the assertion.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked the GET-form case to poll location.search (which settles after the fetch -> swap -> pushState chain) instead of a fixed delay, so it can't flake on a slow runner and the fetched-call lookup is guaranteed present by the time search settles. Re-ran the whole suite several times across the three engines, 9/9 every time. Nothing else moved.

@vivek7405
vivek7405 marked this pull request as ready for review July 12, 2026 04:26
Turbo's form_submission_tests assert a GET form does not incorporate the
current page's URLSearchParams into the submission (only the form fields).
WebJs behaves the same (the absolute action path replaces the search before
the fields are set); lock it so WebJs stays on par with the Turbo router it
derives from. 10 cases, all green on Chromium/Firefox/WebKit.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the Turbo GET-form parity case (a GET form must not merge the current page's query into the submission) after cross-checking against the Turbo router this is derived from. It resolves the absolute action path against location.href, which drops the page's ?sort=old before the form fields set the search, so ?q=new wins. The find() filters on q=new so it doesn't pick up the earlier navigate() fetch of the same path. Ten cases now, stable across the three engines.

@vivek7405
vivek7405 merged commit 45cfe34 into main Jul 12, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/client-router-query-params branch July 12, 2026 04:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dogfood: test client-router query-param preservation on every nav path

1 participant